哈囉,各位邦友們!
從 Day19 的 Reactive Forms 起步,走過了 Signals 與 RxJS 的整合,再到 Standalone 架構與 @defer 。
今天就來總結回顧 Day19~Day25 的內容,同時透過實作來再次加深印象。
Day19 將 Heroes 頁面的新增與編輯改寫為 Reactive Forms,透過 FormBuilder.nonNullable() 建立 HeroFormGroup 並讓 signals 與表單狀態同
步,驗證、重置與畫面顯示一次搞定。
Day20 建立共用錯誤訊息表、同步驗證(長度、pattern)與自訂保留字檢查,還加入非同步驗證避免重複名稱。
另外也將 pending、error 狀態回饋給使用者。
Day21 拆解 signal、computed、effect 的分工,computed 提供派生資料,effect 則負責把 state 與表單、路由同步,透過實作徹底理解響應式運作流程。
Day22 把 HeroDetail 與 Heroes 搜尋的 Observable pipeline 收斂成 toSignal,統一管理 loading/error/value,減少手動訂閱與退訂,Template 渲染也更宣告式。
Day23 介紹 resource() 與 rxResource(),用宣告式 API 接手非同步資料、不必自己煩惱取消請求。
Day24 回顧 Angular 架構演進,了解 bootstrapApplication()、ApplicationConfig、importProvidersFrom() 等核心設計。
@defer 延遲載入策略Day25 聚焦 @defer 的觸發條件與區塊(@placeholder、@loading、@error、prefetch),示範如何延遲 Dashboard 次要內容提升 LCP,建立「需要時才載」的效能思維。
FormBuilder.nonNullable() 建立 FormGroup 與 FormControl,避免 null 對型別的污染。debounce 與 finalize 控制 pending、error,避免畫面卡住。signal 保存清單,再用 computed 建立 Map 快取;所有元件都讀同一份狀態。effect 用來同步路由參數、表單初值與搜尋字串,行為集中且容易測試。toSignal 讓既有 Observable pipeline 直接產生 signal,避免手動 .subscribe() 與退訂。resource()/rxResource() 搭配 AbortSignal,自帶 isLoading()、error()、reload() 等輔助方法。bootstrapApplication() 啟動,ApplicationConfig 集中提供路由、HTTP、SSR、Zoneless。importProvidersFrom() 匯入第三方模組 (HttpClientInMemoryWebApiModule),維持提供者的純度。@defer 針對非關鍵內容選擇 on viewport、on interaction、on idle 等觸發條件,延遲下載重資源。@placeholder、@loading、@error、prefetch 參數控制顯示節奏,保持版面穩定與體驗一致。FormArray 管理多個技能並套用自訂驗證。擴充資料結構與 API Payload
在 hero 模型與 in-memory API 新增 skills: string[] 欄位。
// src/app/hero.service.ts
export type Hero = { id: number; name: string; rank?: string; skills?: string[] };
create(hero: Pick<Hero, 'name' | 'rank' | 'skills'>) { /* ... */ }
update(id: number, changes: Partial<Hero>) { /* ... */ }
// src/app/in-memory-data.ts
const DEFAULT_HEROES: Hero[] = [
  { id: 11, name: 'Dr Nice', rank: 'B', skills: ['Healing', 'Support'] },
  // ...existing heroes...
];
在 HeroesComponent 使用 FormArray 管理技能
以 FormArray 管理多筆技能輸入,並建立避免空白與重複的驗證。
// src/app/heroes/heroes.component.ts
type HeroFormGroup = FormGroup<{
  name: FormControl<string>;
  rank: FormControl<HeroRank>;
  skills: FormArray<FormControl<string>>;
}>;
private buildSkillsForm(initial: readonly string[] = ['']) {
  return this.fb.nonNullable.array(
    (initial.length ? initial : ['']).map((skill) => this.createSkillControl(skill)),
    { validators: [skillsArrayValidator()] }
  );
}
private collectSkills(target: FormArray<FormControl<string>>) {
  return target.controls
    .map((control) => control.value.trim())
    .filter((skill) => skill.length > 0);
}
更新建立/編輯表單範本
在建立與編輯表單介面加入增刪技能欄位與對應樣式。
<!-- src/app/heroes/heroes.component.html -->
<fieldset formArrayName="skills" class="skills">
  <legend>Skills:</legend>
  @for (control of createSkills.controls; track control; let i = $index) {
    <div class="skills__item">
      <input type="text" [formControlName]="i" placeholder="輸入技能" />
      <button type="button" class="muted skills__remove" (click)="removeSkill(createSkills, i)">
        移除
      </button>
      @if (controlError(control); as err) { <small class="error">{{ err }}</small> }
    </div>
  }
  <button type="button" class="muted skills__add" (click)="addSkill(createSkills)">+ 新增技能</button>
  @if (controlError(createSkills); as err) { <small class="error">{{ err }}</small> }
</fieldset>
補齊樣式
// src/app/heroes/heroes.component.scss
.skills {
  display: grid;
  gap: 8px;
  width: 100%;
  padding: 12px;
  border: 1px dashed #d7deef;
  border-radius: 8px;
  background: #ffffff;
}
.skills__item {
  display: flex;
  align-items: center;
  gap: 8px;
}
rxResource() 狀態,支援 reload() 與快取上一次成功資料。建立 rxResource 管理搜尋流程
以 rxResource 接手搜尋結果,並透過 signal 快取上一筆成功資料,在 loading/error 期間維持畫面可用。
// src/app/heroes/heroes.component.ts
// ...existing code...
private readonly searchTerms = new Subject<string>();
private readonly searchTerm = signal<string>('');
private readonly lastSuccessfulSearch = signal<{ term: string; heroes: Hero[] } | null>(null);
protected readonly searchResource = rxResource<Hero[], string>({
  params: () => this.searchTerm(),
  stream: ({ params }) => (params ? this.heroService.search$(params) : of<Hero[]>([])),
  defaultValue: [],
});
protected readonly searchState = computed<SearchState>(() => {
  const term = this.searchTerm();
  if (!term) {
    return { status: 'idle', term: '', heroes: [], message: null, error: null };
  }
  if (this.searchResource.isLoading()) {
    const cached = this.lastSuccessfulSearch();
    return {
      status: 'loading',
      term,
      heroes: cached && cached.term === term ? cached.heroes : [],
      message: '搜尋中...',
      error: null,
    };
  }
  const resourceError = this.searchResource.error();
  if (resourceError) {
    const cached = this.lastSuccessfulSearch();
    return {
      status: 'error',
      term,
      heroes: cached && cached.term === term ? cached.heroes : [],
      message: '查詢失敗,可稍後重試。',
      error:
        resourceError instanceof Error
          ? resourceError.message
          : String(resourceError ?? 'Unknown error'),
    };
  }
  const heroes = this.searchResource.value();
  return {
    status: 'success',
    term,
    heroes,
    message: heroes.length
      ? `命中 ${heroes.length} 位英雄`
      : '沒有符合條件的英雄,試著換個關鍵字。',
    error: null,
  };
});
constructor() {
  // ...existing code...
  this.searchTerms
    .pipe(
      map((term) => term.trim()),
      debounceTime(300),
      distinctUntilChanged(),
      tap(() => this.feedback.set(null)),
      takeUntilDestroyed(this.destroyRef)
    )
    .subscribe((term) => {
      if (term && term.length < 2) {
        this.searchTerm.set('');
        return;
      }
      this.searchTerm.set(term);
    });
  effect(() => {
    const term = this.searchTerm();
    if (!term || this.searchResource.isLoading() || this.searchResource.error()) {
      return;
    }
    this.lastSuccessfulSearch.set({ term, heroes: [...this.searchResource.value()] });
  });
  // ...existing code...
}
覆寫搜尋方法,支援相同關鍵字的 reload()
重複輸入相同關鍵字時改用 rxResource.reload(),也保留顯式重新整理的入口。
// src/app/heroes/heroes.component.ts
// ...existing code...
protected search(term: string) {
  const normalized = (term ?? '').trim();
  if (normalized && normalized.length >= 2 && normalized === this.searchTerm()) {
    this.searchResource.reload();
  }
  this.searchTerms.next(term);
}
protected reloadSearch() {
  if (!this.searchTerm()) {
    return;
  }
  this.searchResource.reload();
}
@defer (on interaction) 搭配 prefetch on idle。新增戰績分析資料計算
建立 battleAnalysis computed,依英雄的 rank、skills 與 id 推導出戰績摘要,供延遲載入區塊使用。
// src/app/hero-detail/hero-detail.ts
// ...existing code...
export class HeroDetail {
  // ...existing code...
  readonly battleAnalysis = computed(() => {
    const detail = this.hero();
    if (!detail) {
      return null;
    }
    const skillsCount = detail.skills?.length ?? 0;
    const rankBoost = (() => {
      switch (detail.rank) {
        case 'S':
          return { win: 18, mvp: 22 };
        case 'A':
          return { win: 12, mvp: 14 };
        case 'B':
          return { win: 6, mvp: 8 };
        case 'C':
          return { win: 2, mvp: 4 };
        default:
          return { win: 0, mvp: 0 };
      }
    })();
    const missions = 32 + (detail.id % 7) * 5 + skillsCount * 3;
    const winRate = Math.min(98, 68 + rankBoost.win + skillsCount * 2);
    const mvpRate = Math.min(72, 18 + rankBoost.mvp + skillsCount * 4);
    const avgDuration = Math.max(9, 28 - rankBoost.win / 2 - skillsCount * 1.5);
    return {
      missions,
      winRate,
      mvpRate,
      avgDuration: Number(avgDuration.toFixed(1)),
      synergyScore: Math.min(100, Math.round(winRate * 0.6 + mvpRate * 0.4)),
    };
  });
  // ...existing code...
}
在模板加上延遲載入區塊
新增「戰績分析」段落,採 @defer (on interaction(trigger); prefetch on idle),互動時載入,閒置時預抓資料;占位內容使用按鈕觸發。
<!-- src/app/hero-detail/hero-detail.html -->
<!-- ...existing code... -->
  <section class="analysis" aria-live="polite">
    <header class="analysis__header">
      <h3>戰績分析</h3>
      <p class="muted">互動後載入精簡戰報。</p>
    </header>
    @defer (on interaction(trigger); prefetch on idle) {
      @if (battleAnalysis(); as stats) {
        <dl class="analysis__stats">
          <div class="analysis__stat">
            <dt>年度任務</dt>
            <dd>{{ stats.missions }} 場</dd>
          </div>
          <div class="analysis__stat">
            <dt>勝率</dt>
            <dd>{{ stats.winRate }}%</dd>
          </div>
          <div class="analysis__stat">
            <dt>MVP 率</dt>
            <dd>{{ stats.mvpRate }}%</dd>
          </div>
          <div class="analysis__stat">
            <dt>平均戰鬥時長</dt>
            <dd>{{ stats.avgDuration }} 分鐘</dd>
          </div>
          <div class="analysis__stat analysis__stat--highlight">
            <dt>協同作戰分數</dt>
            <dd>{{ stats.synergyScore }}</dd>
          </div>
        </dl>
        <p class="analysis__remark">
          {{ detail.name }} 擁有 {{ detail.skills?.length ?? 0 }} 項技能
          @if (detail.skills?.length) {
            ({{ detail.skills?.join('、') }})
          }
          ,綜合表現穩定。
        </p>
      } @else {
        <p class="muted">暫無戰績資料。</p>
      }
    } @placeholder {
      <button #trigger type="button" class="analysis__trigger">查看戰績分析</button>
    }
  </section>
<!-- ...existing code... -->
補上樣式支援戰績卡片
設計背景梯度、統計格線與互動按鈕樣式,與 HeroDetail 既有風格一致。
// src/app/hero-detail/hero-detail.scss
.analysis {
  margin-top: 24px;
  padding: 16px 20px;
  border-radius: 12px;
  background: linear-gradient(135deg, #f4f7ff 0%, #eef2ff 100%);
  box-shadow: 0 6px 18px rgba(32, 56, 117, 0.1);
}
.analysis__header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  margin-bottom: 16px;
}
.analysis__trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  border-radius: 999px;
  border: 1px solid #9aa6d8;
  background: #fff;
  color: #394165;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s ease, color 0.2s ease;
}
.analysis__stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
}
.analysis__stat {
  padding: 12px;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.8);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.analysis__stat--highlight {
  background: rgba(76, 110, 245, 0.12);
  border: 1px solid rgba(76, 110, 245, 0.3);
}
.analysis__remark {
  margin-top: 18px;
  font-size: 0.95rem;
  color: #3a425f;
}
effect 同步 URL 查詢參數與列表篩選,確保重新整理後狀態保持一致建立路由注入與查詢狀態快取
// src/app/heroes/heroes.component.ts
// ...existing code...
import { ActivatedRoute, ParamMap, Router, RouterModule } from '@angular/router';
const VALID_RANK_QUERY_VALUES = new Set(['ALL', 'S', 'A', 'B', 'C']);
type FilterQueryState = { rank: string | null; search: string | null };
export class HeroesComponent {
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);
  private lastSyncedQuery: FilterQueryState = { rank: null, search: null };
  private syncingFromQuery = false;
  // ...existing code...
}
在 constructor 讀取/推送查詢參數
// src/app/heroes/heroes.component.ts
constructor() {
  // ...existing code...
  const initialParams = this.route.snapshot.queryParamMap;
  this.applyQueryParams(initialParams);
  this.route.queryParamMap
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe((params) => this.applyQueryParams(params));
  effect(() => {
    if (this.syncingFromQuery) {
      return;
    }
    const nextState = this.currentQueryState();
    if (this.queryStatesEqual(this.lastSyncedQuery, nextState)) {
      return;
    }
    this.lastSyncedQuery = nextState;
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: {
        rank: nextState.rank,
        search: nextState.search,
      },
      replaceUrl: true,
    });
  });
  // ...existing code...
}
整理共用方法,避免雙向同步時重複觸發
// src/app/heroes/heroes.component.ts
private applyQueryParams(params: ParamMap) {
  this.syncingFromQuery = true;
  try {
    const rankValue = this.normalizeRankFromQuery(params.get('rank'));
    const searchValue = this.normalizeSearchFromQuery(params.get('search'));
    if (this.activeRank() !== rankValue) {
      this.activeRank.set(rankValue);
    }
    if (this.searchTerm() !== searchValue) {
      this.searchTerm.set(searchValue);
    }
    this.lastSyncedQuery = {
      rank: this.formatRankForQuery(rankValue),
      search: this.formatSearchForQuery(searchValue),
    };
  } finally {
    this.syncingFromQuery = false;
  }
}
private currentQueryState(): FilterQueryState {
  return {
    rank: this.formatRankForQuery(this.activeRank()),
    search: this.formatSearchForQuery(this.searchTerm()),
  };
}
private normalizeRankFromQuery(raw: string | null): string {
  const normalized = (raw ?? '').trim().toUpperCase();
  if (!normalized) {
    return 'ALL';
  }
  return VALID_RANK_QUERY_VALUES.has(normalized) ? normalized : 'ALL';
}
private normalizeSearchFromQuery(raw: string | null): string {
  const normalized = (raw ?? '').trim();
  return normalized.length >= 2 ? normalized : '';
}
private formatRankForQuery(rank: string): string | null {
  return rank === 'ALL' ? null : rank;
}
private formatSearchForQuery(term: string): string | null {
  return term.length >= 2 ? term : null;
}
private queryStatesEqual(a: FilterQueryState, b: FilterQueryState): boolean {
  return a.rank === b.rank && a.search === b.search;
}
透過這組 effect 與 helper,URL 查詢參數與搜尋/篩選狀態會保持同步:重新整理、分享連結或手動修改參數,都能還原對應的列表視圖,而且不會造成無限循環或冗餘導航。
驗收清單:
 
 

 
常見錯誤與排查:
FormArray 驗證未觸發:記得在增刪技能時呼叫 markAsTouched() 與 updateValueAndValidity()。skillsArrayValidator() 內以小寫比較是否正確。create()/update() payload 需確保 skills 不為空陣列才送出。reloadSearch() 強制重新查詢。今日小結:
今天一開始擴充了 Heroes 表單,接著將搜尋結果改寫成 rxResource() 狀態,使其支援 reload() 與快取上一次成功資料後,在 HeroDetail 新增了「戰績分析」區塊,最後透過effect 同步 URL 查詢參數與列表篩選,確保重新整理後狀態保持一致,透過這些實作,又再一次複習了Day19~Day25的內容。
參考資料:
resource()/rxResource():@defer: